OXY-17: Add IN / NOT IN value-list predicates to the sql query DSL - #300
Conversation
💬 Feedback from KalinA core design goal of Is that goal unreasonable here? I don't think so — can we tweak it to keep the SQL string static? The obvious candidate is to bind the whole collection as a single array parameter and emit Note this would converge |
Reworked
|
| DSL | SQL (static, one ?) |
|---|---|
p.id.in(ids) |
p.id = ANY(?) |
p.id.notIn(ids) |
p.id <> ALL(?) |
The ? count is always 1 regardless of list size, so QueryContext.sql is a plain constant again. Removed the sentinel token, the InClause runtime expander, the per-execution ctx.sql rewrite, InputEncoder.SeqEncoder, the GeneratedFragment inClauses channel, and GeneratedInClause. batched now works with IN (it was previously disabled because the ? count varied per input).
Reuses OXY-6 / OXY-18 machinery
Encoding goes through RowRepr.ArrayRepr[A](col).encoder (ArraySeqEncoder → InputWriter.unsafeWriteArray → createArrayOf), the same array-bind path introduced by OXY-18 and surfaced as ids.contains(col) in OXY-6 (#298). The runtime Seq[A] is adapted with ArraySeq.untagged.from. No ::type[] cast — the JDBC typed array carries its element type.
⚠️ Convergence flag: this makesin/notIneffectively sugar over OXY-6's= ANY(?). #298 and this PR now overlap heavily on mechanism and differ only in DSL surface (containsvsin/notIn, plus this adds<> ALL(?)negation). Suggest merging one, then rebasing the other onto a shared generation helper and deciding whether both spellings should coexist.
Truth table / edge cases (verified)
= ANY / <> ALL reproduce the exact 3-valued logic of IN / NOT IN:
- Empty (native, no special-casing):
= ANY('{}')→FALSE(likeIN ()),<> ALL('{}')→TRUE(likeNOT IN ()). NOT IN+ NULL footgun: preserved identically, not worsened — and unreachable here (array columns are non-nullable; optional value-lists are rejected).- Single-column element only; const value-lists rejected.
Verification
@compile(debug=true)confirms static templates:WHERE p.id = ANY(?)andWHERE p.groupId = ? AND p.id <> ALL(?).- New static-SQL assertions in the test:
ctx.sql contains "p.id = ANY(?)",!contains "IN (",contains "p.id <> ALL(?)". CustomQuerySpec13/13 pass against real Postgres (testcontainers) — non-empty / single / empty / large /NOT IN/NOT INempty / composition / delete / multi-input interleaving.
Full rationale + decisions in report/OXY-17.md. Confidence: 9/10.
…L(?)`)
Add `col.in(values)` / `col.notIn(values)` to the sql query DSL, filtering a
single column against a runtime collection. The generated SQL is 100% static:
the whole collection binds as a single `java.sql.Array` param, so the SQL text
is identical regardless of list size.
- `col.in(coll)` -> `col = ANY(?)`
- `col.notIn(coll)` -> `col <> ALL(?)`
- value-list may be a `Seq[A]` or a `Set[A]` (`input[Seq[A]]` / `input[Set[A]]`).
- empty collection handled natively by ANY/ALL: `= ANY('{}')` is FALSE,
`<> ALL('{}')` is TRUE -- the exact truth table of SQL `IN ()` / `NOT IN ()`.
- reuses OXY-6's array-bind machinery (`RowRepr.ArrayRepr` / `ArraySeqEncoder`);
`in` / `notIn` are effectively sugar over OXY-6's `= ANY(?)` path.
- single-column element types only; the value-list `input` must be non-optional.
- docs + it-test coverage (non-empty / single / empty / large / NOT IN /
composition / delete / Set), incl. static-SQL `ctx.sql` assertions.
Report captured on the OXY-17 Jira issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Adds
IN/NOT INvalue-list support to theoxygen-sql@compilequery DSL:Filters a single column against a runtime
Seq[A]with expanded placeholderscol IN (?, ?, ...)/NOT IN (...).How
The
?count depends on the runtime list length, which is unknown whenQueryContext.sqlis baked at construction. So the compiled SQL carries a unique sentinel token where the predicate goes, plus a runtimeInClauseexpander that renders the final SQL per-execution:IN->FALSE, emptyNOT IN->TRUE(never the illegalIN ()).InputEncoder.SeqEncoder, embedded in the normal encoder chain, so multi-input interleaving stays correct with no execute-time special-casing.Kept deliberately distinct from OXY-6's single-array
= ANY(?)/UNNEST.Decisions (see report for full list)
col.in(coll)/col.notIn(coll); collection isSeq[A](Set ->.toSeq).notInfirst-class (clean empty-list rewrite). SubqueryIN (SELECT ...)out of scope.batched+ dynamic IN unsupported (throws); value-list must be a runtimeinput, notconst.Verification
oxygen-sql+sql-it/Testcompile clean.CustomQuerySpec"in / notIn (OXY-17)" runs against a real Postgres (testcontainers) and passes: non-empty / single / empty(FALSE) / large lists, NOT IN, NOT IN empty(TRUE), composition, delete-by-IN, multi-input interleaving.CustomQuerySpec(13 tests) passes — no regressions.docs/docs/sql/queries.md), cross-linked to OXY-6.Confidence: 8.5/10
High confidence in the implemented scope (green integration tests, localized design). Residual risk: IN embedded in subqueries / INSERT-FROM-SELECT not explicitly tested; const-list & Set inputs intentionally unsupported.
Full notes + assumptions:
report/OXY-17.md.🤖 Generated with Claude Code